Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit a5e6894fe8934aefce5036458407a69fc96ca541


Parents : 321b73e
Author : Ben Meadors <benmmeadors@gmail.com>
Signature : Signature validation error
Date : 2026-05-28T12:33:16-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-05-28T17:33:16Z

Enhance TAKTALK support with message and room handling, update SDK to v0.3.2 (#5634)

Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Changes
Diff

diff --git a/core/takserver/build.gradle.kts b/core/takserver/build.gradle.kts
index 0c0405bf7c..a89201ec3e 100644
--- a/core/takserver/build.gradle.kts
+++ b/core/takserver/build.gradle.kts
@@ -76,5 +76,13 @@ kotlin {
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}
+
+ val androidHostTest by getting {
+ dependencies {
+ // Host-JVM tests need the platform JAR (not AAR) for zstd native
+ // libs β€” the @aar from androidMain only ships ARM/x86 .so files.
+ implementation("com.github.luben:zstd-jni:1.5.7-9")
+ }
+ }
}
}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
index ce14df9cda..c01b0dd581 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
@@ -338,7 +338,7 @@ class TAKMeshIntegration(
.replace("""<?xml version="1.0" encoding="UTF-8"?>""", "")
.replace(Regex("""\s*\n\s*"""), "")
.trim()
- // Logger.d { "RAW CoT IN (mesh): $xml" }
+ Logger.d { "RAW CoT IN (mesh): $xml" }
// Routes: ATAK ignores b-m-r CoT events over TCP streaming.
// Convert to a KML data package and write to ATAK's auto-import dir.
if (xml.contains("""type="b-m-r"""")) {
@@ -489,10 +489,16 @@ class TAKMeshIntegration(
listOf(
"""<takv[^>]*/>""", // TAK version (self-closing)
"""<takv[^>]*>.*?</takv>""", // TAK version (paired)
- """<voice[^>]*/>""", // voice chat state
- """<voice[^>]*>.*?</voice>""",
- """<marti[^>]*/>""", // empty marti
- """<marti[^>]*>.*?</marti>""",
+ // NOTE: <voice/>, <voice_profile_id*>, and <marti> are
+ // intentionally NOT stripped here. SDK v0.3.2 carries them
+ // end-to-end so TAKTALK receivers can:
+ // * fire TTS playback on <voice/> + <marti><dest callsign=ME>
+ // * recognize TAKTALK-origin chats via <voice_profile_id>
+ // Stripping these saves ~10-20B per packet but breaks TAKTALK
+ // voice messaging and directed-chat routing on the receiver β€”
+ // an unacceptable tradeoff. The previous broad pattern
+ // `<voice[^>]*/>` ALSO matched `<voice_profile_id/>`, which is
+ // why TAKTALK b-t-f chats lost their TAKTALK-origin marker too.
"""<__geofence[^>]*/>""", // geofence config
"""<__geofence[^>]*>.*?</__geofence>""",
"""<tog[^>]*/>""", // toggle state

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
index b422a8a122..d4dde246af 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
@@ -64,6 +64,15 @@ class TakMeshTestRunner(private val commandSender: CommandSender) {
/** All bundled fixture filenames. */
val FIXTURE_NAMES =
listOf(
+ // TAK-Talk sanity test β€” first in the list so the operator's
+ // initial "Send Test CoTs" tap immediately exercises the
+ // most-stress-tested path on the receiver: m-t-t with
+ // <voice/> push-to-talk marker + <marti> directed routing.
+ // If the receiver's TAKTALK plugin plays a TTS clip for this
+ // single send, the v0.3.2 round-trip pipeline (sender callsign
+ // implicit, marti carried, no spurious <contact>) is wired
+ // end-to-end on both ends of the mesh.
+ "taktalk_sanity.xml",
"aircraft_adsb.xml",
"aircraft_hostile.xml",
"alert_tic.xml",

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorTaktalkTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorTaktalkTest.kt
new file mode 100644
index 0000000000..7791491007
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorTaktalkTest.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import org.meshtastic.proto.CotType
+import org.meshtastic.proto.GeoChat
+import org.meshtastic.proto.TAKPacketV2
+import org.meshtastic.proto.TakTalkMessage
+import org.meshtastic.proto.TakTalkRoomData
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Regression tests for the TakV2Compressor adapter layer's TAKTALK handling.
+ *
+ * Before 0.3.0, the wire-to-SDK and SDK-to-wire converters in TakV2Compressor.kt had no cases for [TakTalkMessage] or
+ * [TakTalkRoomData]. m-t-t and y- events therefore fell through to [TakPacketV2Data.Payload.None] on the receive side,
+ * which surfaced as TAKTALK voice push-to-talk messages never reaching the TTS stage on the receiving ATAK device. Chat
+ * (b-t-f) was wired but its TAKTALK sidecars (lang, room_id, voice_profile_id) were dropped too.
+ *
+ * These tests pin the round-trip so any future regression of the same adapter gaps fails fast at unit-test time instead
+ * of in the field.
+ */
+class TakV2CompressorTaktalkTest {
+
+ @Test
+ fun `m-t-t taktalk message round-trips with voice marker`() {
+ val original =
+ TAKPacketV2(
+ cot_type_id = CotType.CotType_m_t_t,
+ callsign = "ASPEN",
+ uid = "TAKTALK-MESSAGE-81b44b48-21c5-4edf-aab2-b6bd2ec1f52f",
+ taktalk =
+ TakTalkMessage(
+ text = "Testing 123, testing 123.",
+ chatroom_id = "1",
+ lang = "English",
+ from_voice = true,
+ ),
+ )
+
+ val wire = TakV2Compressor.compress(original)
+ val decompressed = TakV2Compressor.decompress(wire)
+
+ assertEquals(CotType.CotType_m_t_t, decompressed.cot_type_id)
+ assertEquals("ASPEN", decompressed.callsign)
+ assertNotNull(decompressed.taktalk, "taktalk variant must survive round-trip")
+ assertEquals("Testing 123, testing 123.", decompressed.taktalk!!.text)
+ assertEquals("1", decompressed.taktalk!!.chatroom_id)
+ assertEquals("English", decompressed.taktalk!!.lang)
+ assertTrue(decompressed.taktalk!!.from_voice, "<voice/> marker must survive")
+ assertNull(decompressed.pli, "must not fall back to Pli payload")
+ }
+
+ @Test
+ fun `m-t-t taktalk text-only message has from_voice false`() {
+ val original =
+ TAKPacketV2(
+ cot_type_id = CotType.CotType_m_t_t,
+ callsign = "ETHEL",
+ uid = "TAKTALK-MESSAGE-text-only",
+ taktalk =
+ TakTalkMessage(text = "typed message", chatroom_id = "1", lang = "English", from_voice = false),
+ )
+
+ val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
+
+ assertNotNull(decompressed.taktalk)
+ assertEquals("typed message", decompressed.taktalk!!.text)
+ assertEquals(false, decompressed.taktalk!!.from_voice)
+ }
+
+ @Test
+ fun `y- taktalk room broadcast round-trips with participant list`() {
+ val original =
+ TAKPacketV2(
+ cot_type_id = CotType.CotType_y,
+ uid = "ROOM-DATA-5eac9336-2698-4dbb-9fa2-62b100e469fe",
+ // v0.3.2: sender identity lives on envelope packet.callsign,
+ // not on the deprecated TakTalkRoomData.sender_callsign field.
+ // The SDK builder reconstitutes <sender-callsign> from envelope
+ // callsign on emit.
+ callsign = "ASPEN",
+ taktalk_room =
+ TakTalkRoomData(
+ room_id = "30b2755c-c547-44ef-a0cc-cdbd8a15616f",
+ room_name = "test",
+ participants = listOf("ETHEL", "ASPEN"),
+ ),
+ )
+
+ val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
+
+ assertEquals(CotType.CotType_y, decompressed.cot_type_id)
+ assertNotNull(decompressed.taktalk_room, "taktalk_room variant must survive round-trip")
+ assertEquals(
+ "ASPEN",
+ decompressed.callsign,
+ "v0.3.2: <sender-callsign> routes to envelope callsign on round-trip",
+ )
+ assertEquals("30b2755c-c547-44ef-a0cc-cdbd8a15616f", decompressed.taktalk_room!!.room_id)
+ assertEquals("test", decompressed.taktalk_room!!.room_name)
+ assertEquals(listOf("ETHEL", "ASPEN"), decompressed.taktalk_room!!.participants)
+ }
+
+ @Test
+ fun `m-t-t with marti round-trips dest callsigns and survives compression`() {
+ val original =
+ TAKPacketV2(
+ cot_type_id = CotType.CotType_m_t_t,
+ uid = "TAKTALK-MESSAGE-marti-test-uuid",
+ callsign = "ASPEN",
+ taktalk =
+ TakTalkMessage(
+ text = "Push-to-talk to ETHEL",
+ chatroom_id = "1",
+ lang = "English",
+ from_voice = true,
+ ),
+ // Directed-routing recipient list. TAKTALK gates voice TTS on
+ // this list matching the receiver's callsign β€” a regression
+ // here silently breaks voice messaging end-to-end.
+ marti = org.meshtastic.proto.Marti(dest_callsign = listOf("ETHEL")),
+ )
+
+ val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
+
+ assertEquals(CotType.CotType_m_t_t, decompressed.cot_type_id)
+ assertEquals("ASPEN", decompressed.callsign)
+ assertNotNull(decompressed.marti, "marti must survive compression round-trip")
+ assertEquals(
+ listOf("ETHEL"),
+ decompressed.marti!!.dest_callsign,
+ "marti.dest_callsign must round-trip unchanged so TAKTALK can resolve TTS targets",
+ )
+ }
+
+ @Test
+ fun `b-t-f chat carries TAKTALK sidecars through compressor`() {
+ val original =
+ TAKPacketV2(
+ cot_type_id = CotType.CotType_b_t_f,
+ callsign = "ASPEN",
+ uid = "GeoChat.test",
+ chat =
+ GeoChat(
+ message = "Test message",
+ lang = "English",
+ room_id = "30b2755c-c547-44ef-a0cc-cdbd8a15616f",
+ voice_profile_id = "", // empty marker `<voice_profile_id/>`
+ ),
+ )
+
+ val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
+
+ assertNotNull(decompressed.chat)
+ assertEquals("Test message", decompressed.chat!!.message)
+ assertEquals("English", decompressed.chat!!.lang, "TAKTALK <Ea> lang must survive")
+ assertEquals(
+ "30b2755c-c547-44ef-a0cc-cdbd8a15616f",
+ decompressed.chat!!.room_id,
+ "TAKTALK <roomId> must survive",
+ )
+ assertNotNull(
+ decompressed.chat!!.voice_profile_id,
+ "empty <voice_profile_id/> marker must survive as empty string",
+ )
+ assertEquals("", decompressed.chat!!.voice_profile_id)
+ }
+
+ @Test
+ fun `b-t-f chat without TAKTALK sidecars leaves them null on the wire`() {
+ // Plain GeoChat from non-TAKTALK ATAK β€” no sidecars present. The
+ // round-trip must not synthesize empty-string sidecars that would
+ // change the rebuilt CoT XML for plain chat messages.
+ val original =
+ TAKPacketV2(
+ cot_type_id = CotType.CotType_b_t_f,
+ callsign = "ALPHA-1",
+ uid = "GeoChat.plain",
+ chat =
+ GeoChat(
+ message = "plain chat",
+ // no lang, no room_id, no voice_profile_id
+ ),
+ )
+
+ val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
+
+ assertNotNull(decompressed.chat)
+ assertEquals("plain chat", decompressed.chat!!.message)
+ assertNull(decompressed.chat!!.lang, "non-TAKTALK chat should have null lang on the wire")
+ assertNull(decompressed.chat!!.room_id, "non-TAKTALK chat should have null room_id")
+ assertNull(decompressed.chat!!.voice_profile_id, "non-TAKTALK chat should have null voice_profile_id")
+ }
+}

diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt
index 689afbaf99..4ca80c6693 100644
--- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt
@@ -185,7 +185,7 @@ internal class TAKClientConnection(
// Emitted at debug level so it's always available in logcat for field
// debugging without needing a release rebuild. Not truncated β€” the
// reader of this log needs the complete event to reproduce issues.
- // Logger.d { "RAW CoT IN (TCP ${currentClientInfo.id}): $xmlString" }
+ Logger.d { "RAW CoT IN (TCP ${currentClientInfo.id}): $xmlString" }
val parser = CoTXmlParser(xmlString)
val result = parser.parse()
@@ -246,7 +246,7 @@ internal class TAKClientConnection(
// CoTMessage β†’ XML round trip. This is the exact bytes the client
// will receive, so logging here closes the debugging loop with the
// matching RAW CoT IN line on the receiver.
- // Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): $xml" }
+ Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): $xml" }
sendXmlInternal(xml)
}
@@ -264,7 +264,7 @@ internal class TAKClientConnection(
* preserve shape detail elements.
*/
fun sendRawXml(xml: String) {
- // Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" }
+ Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" }
sendXmlInternal(xml)
}

diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
index 97de4e269a..102b72f7a9 100644
--- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
@@ -28,6 +28,8 @@ import org.meshtastic.proto.GeoChat as WireGeoChat
import org.meshtastic.proto.Marker as WireMarker
import org.meshtastic.proto.RangeAndBearing as WireRangeAndBearing
import org.meshtastic.proto.Route as WireRoute
+import org.meshtastic.proto.TakTalkMessage as WireTakTalkMessage
+import org.meshtastic.proto.TakTalkRoomData as WireTakTalkRoomData
import org.meshtastic.proto.TaskRequest as WireTaskRequest
import org.meshtastic.proto.Team as WireTeam
import org.meshtastic.tak.TakCompressor as SdkCompressor
@@ -104,6 +106,41 @@ internal actual object TakV2Compressor {
toCallsign = packet.chat!!.to_callsign,
receiptForUid = packet.chat!!.receipt_for_uid,
receiptType = packet.chat!!.receipt_type.value,
+ // TAKTALK sidecars (proto3 optional β†’ wire nullable).
+ // Empty string = empty `<Ea/>` / `<roomId/>` in source XML;
+ // null on the wire = field absent. The SDK's Chat data class
+ // uses "" for absent, so map null β†’ "". voice_profile_id has
+ // a present-vs-empty-marker distinction tracked separately
+ // via hasVoiceProfile.
+ lang = packet.chat!!.lang ?: "",
+ roomId = packet.chat!!.room_id ?: "",
+ voiceProfileId = packet.chat!!.voice_profile_id ?: "",
+ hasVoiceProfile = packet.chat!!.voice_profile_id != null,
+ )
+
+ // TAKTALK voice/text message (m-t-t). Without this branch,
+ // m-t-t events fall through to Payload.None and the receiver
+ // can't rebuild the CoT event, so TTS playback never fires.
+ packet.taktalk != null ->
+ TakPacketV2Data.Payload.TakTalk(
+ text = packet.taktalk!!.text,
+ chatroomId = packet.taktalk!!.chatroom_id,
+ lang = packet.taktalk!!.lang,
+ fromVoice = packet.taktalk!!.from_voice,
+ )
+
+ // TAKTALK room/membership broadcast (y-).
+ packet.taktalk_room != null ->
+ @Suppress("DEPRECATION")
+ TakPacketV2Data.Payload.TakTalkRoom(
+ // sender_callsign deprecated in SDK v0.3.2 β€” sender
+ // identity is now in envelope packet.callsign;
+ // v0.3.1 packets still populate the legacy field so
+ // we forward it for source-compat readers.
+ senderCallsign = packet.taktalk_room!!.sender_callsign,
+ roomId = packet.taktalk_room!!.room_id,
+ roomName = packet.taktalk_room!!.room_name,
+ participants = packet.taktalk_room!!.participants.toList(),
)
packet.aircraft != null ->
@@ -274,6 +311,12 @@ internal actual object TakV2Compressor {
takOs = packet.tak_os,
endpoint = packet.endpoint,
phone = packet.phone,
+ // Directed-routing recipient callsigns (<marti><dest …/>…</marti>).
+ // Empty list = broadcast (default); populated for TAKTALK m-t-t,
+ // directed b-t-f DMs, and any other CoT shape that ATAK addresses
+ // to specific peers. Without this field the receive-side rebuild
+ // drops <marti>, breaking TAKTALK voice TTS.
+ marti = packet.marti?.dest_callsign?.toList() ?: emptyList(),
payload = payload,
)
}
@@ -327,6 +370,13 @@ internal actual object TakV2Compressor {
receipt_type =
WireGeoChat.ReceiptType.fromValue(chat.receiptType)
?: WireGeoChat.ReceiptType.ReceiptType_None,
+ // TAKTALK sidecars. Empty SDK string β†’ wire null (field absent)
+ // so non-TAKTALK chats don't carry empty sidecar bytes on every
+ // mesh packet. voice_profile_id stays present-but-empty when
+ // hasVoiceProfile=true so the receiver can re-emit `<voice_profile_id/>`.
+ lang = chat.lang.ifEmpty { null },
+ room_id = chat.roomId.ifEmpty { null },
+ voice_profile_id = if (chat.hasVoiceProfile) chat.voiceProfileId else null,
)
},
aircraft =
@@ -476,6 +526,42 @@ internal actual object TakV2Compressor {
)
},
raw_detail = (data.payload as? TakPacketV2Data.Payload.RawDetail)?.bytes?.toByteString(),
+ // TAKTALK voice/text message (m-t-t). Without this, m-t-t events
+ // would compress with no payload set, the receiver's wireToSdkData
+ // would fall through to Payload.None, and TAKTALK plugin would
+ // never see the rebuilt CoT event for TTS playback.
+ taktalk =
+ (data.payload as? TakPacketV2Data.Payload.TakTalk)?.let { tt ->
+ WireTakTalkMessage(
+ text = tt.text,
+ chatroom_id = tt.chatroomId,
+ lang = tt.lang,
+ from_voice = tt.fromVoice,
+ )
+ },
+ // TAKTALK room/membership broadcast (y-). Required for receivers
+ // to resolve TAKTALK room UUIDs to friendly names + rosters.
+ taktalk_room =
+ (data.payload as? TakPacketV2Data.Payload.TakTalkRoom)?.let { room ->
+ @Suppress("DEPRECATION")
+ WireTakTalkRoomData(
+ // sender_callsign deprecated in SDK v0.3.2 β€” the SDK
+ // builder reconstitutes <sender-callsign> from envelope
+ // packet.callsign, so we stop emitting the duplicate
+ // wire byte. Field stays present for one release so
+ // v0.3.1 receivers continue decoding cleanly.
+ sender_callsign = "",
+ room_id = room.roomId,
+ room_name = room.roomName,
+ participants = room.participants.toList(),
+ )
+ },
+ // Directed-routing recipient list (<marti><dest …/>…</marti>).
+ // Empty list = broadcast (default); populated for TAKTALK m-t-t
+ // and directed b-t-f DMs. Encode an explicit Marti only when
+ // there is at least one destination β€” the wrapper costs wire
+ // bytes for no benefit on broadcast packets.
+ marti = data.marti.takeIf { it.isNotEmpty() }?.let { org.meshtastic.proto.Marti(dest_callsign = it) },
)
}
}

diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/taktalk_sanity.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/taktalk_sanity.xml
new file mode 100644
index 0000000000..1a59859af1
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/taktalk_sanity.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<event version="2.0" uid="TAKTALK-MESSAGE-sanity-test-aspen-to-ethel" type="m-t-t" how="null" time="2026-05-27T20:00:00.000Z" start="2026-05-27T20:00:00.000Z" stale="2026-05-27T20:06:00.000Z">
+ <point lat="0.0" lon="0.0" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
+ <detail>
+ <callsign>ASPEN</callsign>
+ <lang>English</lang>
+ <text>TAK-Talk sanity test from ASPEN.</text>
+ <chatroom-id>1</chatroom-id>
+ <voice/>
+ <marti><dest callsign="ETHEL"/></marti>
+ </detail>
+</event>

Served by rngit 1.5.0 - Generated in 0.13s